前面已經選定目標系統使用的技術,固定開發環境與相依版本,也確認框架及共用元件的責任。開始實作功能前,還要把這些決定轉成全體開發人員都能遵循的程式碼規範。否則相同技術仍可能因命名、錯誤處理、模組相依或測試方式不同,逐漸形成多套互不相容的寫法。
程式碼規範的目的,是讓程式的結構與意圖容易判斷,並且在變更進入共同版本前找出可以預防的問題。格式等機械性規則適合交由工具處理,責任邊界與抽象範圍則需要透過文件、範例及程式碼審查確認。兩者缺一不可。
規範不應該只列出「命名要清楚」或「程式碼要整齊」等原則。每一條規則都要能回答適用範圍、判斷方式及違反後的處理方法。可以按照下列類型整理:
| 規則類型 | 適合處理的內容 | 落實方式 |
|---|---|---|
| 自動修正 | 縮排、空白、換行、引號、匯入順序等不需要人工判斷的格式 | 使用固定版本的格式化工具統一修改 |
| 自動檢查 | 未使用項目、不可到達的程式碼、型別問題、危險語法及明確禁止的相依方向 | 由程式碼檢查工具與靜態分析工具阻擋違規變更 |
| 人工判斷 | 命名是否表達意圖、模組責任是否合理、抽象是否過度及錯誤資訊是否足夠 | 提供判斷原則與正反例,在程式碼審查時確認 |
| 條件規則 | 只適用特定語言、框架、產生檔案或整合邊界的要求 | 明確標示適用範圍,不擴大成所有程式都要遵守的限制 |
規範的優先順序也要清楚。程式語言及框架的官方慣例可以作為起點,再加入目標系統確實需要的限制。不同工具如果對同一格式提出衝突要求,應該選定單一結果並停用重複規則,避免開發人員反覆修改仍無法通過檢查。
規範文件至少要記錄規則內容、採用原因、檢查方式、正反例及例外程序。只保存工具設定,無法說明架構類規則的判斷理由。只有文字文件而沒有對應設定,也容易讓可以自動處理的問題持續進入程式碼。
每一條規則可以設定固定識別碼、適用範圍、強制程度、檢查方式、維護人員及重新檢查條件。程式碼檢查結果、審查意見與例外紀錄就能引用同一條規則,不必依賴容易改變的標題或段落位置。規則文字修訂後,識別碼仍應該保持不變,讓過去的紀錄可以繼續追蹤。
例如,ESLint 的 no-undef 是用來找出未宣告名稱的常見規則識別碼。下列 TypeScript 程式使用了尚未宣告的 recordCount:
export function calculateRecordCount(records: unknown[]) {
return recordCount + records.length;
}
如果啟用 no-undef,檢查結果會在訊息最後標示規則識別碼:
src/records/calculate-record-count.ts
2:12 error 'recordCount' is not defined no-undef
設定檔、檢查結果、程式碼審查意見與例外紀錄都可以引用 no-undef,讓開發人員直接找到對應規則。TypeScript 編譯器本身已經能檢查未宣告名稱,因此只包含 TypeScript 的專案通常不需要重複啟用 no-undef。同時包含 JavaScript 與 TypeScript 時,可以只對 JavaScript 啟用這項規則,TypeScript 則使用編譯器的檢查結果。
強制程度可以區分為阻擋、警告與建議。阻擋規則必須在變更進入共同版本前處理,警告需要確認風險或留下例外紀錄,建議則提供改善方向,不應該因個人偏好阻止變更。維護責任可以指定給角色或程式範圍,不必綁定單一人員。
名稱要讓讀者在不開啟實作內容的情況下,大致判斷一個項目的用途。具體大小寫、字首或檔名格式應該依照已選定語言及框架的慣例決定,本章不指定跨語言都必須相同的寫法。含糊名稱、相似拼字、隱藏的狀態變更及責任過多的函式,都會增加理解與修改成本。
不同技術對變數、函式、類別、介面及檔案名稱有不同慣例。專案應該按照已選定語言與框架的慣例,分別定義變數、函式、類別、介面及檔案使用的格式。同一類項目要維持一致,不要因個人偏好混用多種方式。如果系統使用資料庫,還要定義資料庫物件的命名方式,並考量資料模型用詞及遷移工具需要的穩定性。
縮寫、底線或其他字首只有在專案中定義清楚、沒有歧義,而且符合所選技術慣例時才適合使用。型別、存取範圍或其他可以由開發工具直接顯示的資訊,通常不需要重複編入名稱。
常見格式包括:
將第一個單字以小寫開頭,後續單字的第一個字母改為大寫。
setValue, getValue, convertValue...
將每個單字的第一個字母改為大寫。
BasicComponent, CommonInterface, AbstractClass...
將單字全部改為小寫,並使用底線分隔。
set_value, get_value, convert_value
將單字全部改為小寫,並使用連字號分隔。
basic-component, common-interface, abstract-class
連字號命名法不適用於程式語言中的變數、函式、類別、介面等識別字命名。多數程式語言會將 - 解讀為減法運算子,或不允許它出現在識別字中。只有在所選技術明確支援時,才適合用於檔名、設定鍵、路徑片段或其他非程式語言識別內容。
格式一致只能讓名稱的外觀統一,名稱本身仍要表達用途、責任或狀態。下列範例中,較清楚的名稱讓讀者可以區分原始內容、處理後的結果及執行的動作:
data、text、doThis、convertData...abc、r、s、t...data1、data2、data3...add、increase...sourceRecord、normalizedRecord、normalizeSourceRecord
i 表示索引。離開該範圍後,應該改用 index 或其他能說明用途的名稱。addSourceRecord、removeSourceRecord、findSourceRecord
SourceRecord 統一表示相同概念,再使用不同動詞說明實際動作。isFormatValid、hasRequiredValue、canRetry
is、has 或 can 等字首,讓讀者可以預期內容為肯定或否定的判斷結果。sourceRecord、sourceRecords
清楚的名稱不一定最長。判斷重點是名稱能否在目前範圍內提供足夠資訊,並讓相同概念在不同位置維持一致。
名稱、作用範圍與實際行為要一起判斷。讀者應該能從名稱與公開介面看出資料從哪裡進入、在哪裡改變,以及函式是否會更新狀態。隱藏的資料來源或狀態變更會增加理解與修改風險。
變數與函式還要遵守下列原則:
is、has、find 或 check 表達查詢或判斷時,不要加入名稱沒有表達的狀態變更。如果確實需要修改狀態,應該拆分責任或讓介面清楚呈現副作用(Side Effect)。部分語言的程式碼檢查工具可以找出變數名稱遮蔽或未使用結果,但工具無法判斷所有名稱與行為是否一致。這類規則仍要搭配正反例及程式碼審查。
函式接收可變資料時,規範要說明能否直接修改傳入內容、傳回結果是否與原內容共用狀態,以及哪一方負責後續變更。如果沒有明確約定,呼叫端可能以為原內容不會改變,卻在其他位置觀察到非預期結果。
例如,下列函式建立新物件,不會修改呼叫端傳入的內容:
function renameRecord(record, name) {
return {
...record,
name,
};
}
const sourceRecord = { id: 1, name: "Original" };
const renamedRecord = renameRecord(sourceRecord, "Updated");
專案不一定要禁止所有原地修改。確實需要修改傳入內容時,名稱、型別或公開介面應該清楚表達這項行為,並限制可以修改的位置。跨模組傳遞的可變資料也要定義擁有者,避免多個位置在沒有協調的情況下更新相同狀態。
程式行數較少不代表更容易維護。巢狀條件、過度壓縮的運算式或不常見語法,可能要求讀者同時追蹤多個分支及狀態。固定值、條件分支、迴圈與遞迴都需要清楚表達用途、邊界及結束條件,避免必須逐行模擬才能確認結果。
魔術數字(Magic Number)是直接出現在程式中,但名稱與上下文不足以說明用途的數值。例如,重試次數、長度限制或狀態代碼直接寫成 3、20 或其他數值時,讀者可能無法判斷它們代表的規則,也不知道多個相同數值是否需要一起修改。
let connection = null;
let connectionAttemptCount = 0;
while (
(connection === null || !connection.ready) &&
connectionAttemptCount < 3 // 3 是甚麼?
) {
connection = new Connection();
connectionAttemptCount++;
}
會表達功能規則或可能調整的數值,應該改用名稱清楚的常數、列舉值或設定欄位,並記錄單位及適用範圍。如果數值來自外部規格,還要保留規格位置或轉換規則。環境之間可能不同的內容,則應該由已定義的設定方式提供,不要散布在各個函式中。
let connection = null;
let connectionAttemptCount = 0;
const maxConnectionAttempts = 3;
while (
(connection === null || !connection.ready) &&
connectionAttemptCount < maxConnectionAttempts
) {
connection = new Connection();
connectionAttemptCount++;
}
maxConnectionAttempts 表示最多嘗試三次,而且包含第一次建立連線。如果規則要表達首次失敗後還能重試三次,名稱與判斷方式就要改成最多四次嘗試,避免把「嘗試次數」與「重試次數」視為相同概念。
不需要為所有數值建立常數。迴圈起始值、空集合長度或演算法中意義明確的 0、1,如果上下文已經能直接說明用途,可以保留原值。判斷重點是修改數值時,是否能知道原因、影響範圍及需要同步調整的位置。
// 從陣列最後一個元素開始,直到陣列第一個元素
// 這裡的 0 與 1 都具有明確定義,因此可以不需要額外說明
for (let i = array.length() - 1; i >= 0; i--) {
array[i];
}
if (a === b) {
if (a !== c && (c === d || c === e)) {
handleFirstCase();
}
if (b === c && c !== d) {
handleSecondCase();
}
}
多層 if-else 與迴圈會增加同時需要追蹤的條件、狀態及離開方式。可以先處理無效輸入與不符合條件的情況,讓主要流程留在較外層。重複或具有獨立目的的判斷與迴圈內容,也可以抽成名稱清楚的函式,讓呼叫位置直接表達步驟。
if (a !== b) return;
if (a !== c && (c === d || c === e)) {
handleFirstCase();
}
if (b === c && c !== d) {
handleSecondCase();
}
如果多個 if-else 分支都在比較同一個值,而且選項固定、互斥,所選語言支援的 switch、match 或相同用途的結構可能更容易閱讀。條件包含範圍、複合判斷、執行順序或不同副作用時,改用這些結構未必更清楚。此時應該拆分判斷責任,或使用能直接表達規則的對映與決策結構。
let a = random(0, 5);
switch (a) {
case 0: /* 不同狀態下的處理流程 */ break;
case 1: break;
case 2: break;
case 3: break;
case 4: break;
case 5: break;
default: /* 不符合上述狀態時的處理流程 */ break;
}
巢狀層級沒有適用所有程式的固定上限。規範可以搭配程式碼檢查工具設定複雜度門檻,但仍要由實際流程確認拆分後沒有改變執行順序、提早結束條件或狀態變更。
function traverse(node) {
if (!node) return;
// 進行處理
console.log(node.value);
if (node.left) traverse(node.left);
if (node.right) traverse(node.right);
return;
}
遞迴適合表達可重複分解,而且具有明確終止條件的結構。使用時要定義每次呼叫如何縮小問題、何時停止,以及最大深度是否可能超過執行環境可以承受的範圍。如果輸入可能形成循環關係,還要記錄已處理項目或採用其他方式防止重複進入相同節點。
處理深度無法預估,或流程可以用迴圈清楚表達時,應該優先使用顯式堆疊(Stack)保存待處理項目。程式可以自行決定加入、取出與停止條件,不必依賴函式呼叫堆疊,也比較容易限制處理數量、記錄已處理項目及在必要時中止。
function traverse(node) {
if (!node) return;
const stack = [node];
const visited = [];
while (stack.length > 0) {
const current = stack.pop();
if (visited.includes(current)) continue;
// 進行處理
console.log(current.value);
visited.push(current);
if (current.right) stack.push(current.right);
if (current.left) stack.push(current.left);
}
return;
}
使用顯式堆疊時,要定義項目加入順序、取出順序、最大待處理數量及循環關係的處理方式,並確認執行順序是否和原本遞迴一致。確定保留遞迴時,則要測試空內容、最小內容、最大預期深度、終止條件不成立時的保護方式,以及循環關係等情況。
格式規則應該由格式化工具產生唯一結果。專案需要保存工具版本與設定,並提供共同指令,讓本機開發和自動化流程使用相同方式檢查。開發人員不需要在程式碼審查中反覆討論空白、換行或括號位置。
除了基本格式,還要定義下列項目:
例如,JavaScript 專案可以使用 JSDoc 說明較複雜函式的用途與公開介面:
/**
* 篩選符合條件的紀錄,轉換數值精度後依識別碼排序。
*
* 函式會建立新陣列,不會改變呼叫端傳入的內容。
*
* @param {Object[]} records - 待處理的紀錄。
* @param {number} records[].id - 紀錄識別碼。
* @param {number} records[].value - 需要整理的數值。
* @param {number} [minimumValue=0] - 納入結果的最小數值。
* @returns {Object[]} 依識別碼排序的新紀錄陣列。
* @throws {TypeError} records 不是陣列時拋出。
*/
function normalizeRecords(records, minimumValue = 0) {
if (!Array.isArray(records)) {
throw new TypeError("records 必須是陣列");
}
return records
.filter((record) => {
return (
Number.isFinite(record?.id) &&
Number.isFinite(record?.value) &&
record.value >= minimumValue
);
})
.map((record) => ({
id: record.id,
value: Math.round(record.value * 100) / 100,
}))
.sort((left, right) => left.id - right.id);
}
公開介面如果需要文件,應該說明使用條件、輸入限制、結果、可能失敗方式及可觀察的狀態變化。文件內容要和實作一起修改。長期與實作不一致的註解,比沒有註解更容易造成錯誤判斷。
檔案及目錄應該按照功能或責任組織,不因個人習慣任意建立另一套分類方式。自動產生的檔案要放在可辨識的範圍,記錄產生來源及更新方式,避免和人工維護內容混在一起。
例如,使用 JavaScript 的專案可以依照功能責任分組,並分開保存測試、自動產生內容及開發工具:
project/
├── src/
│ ├── records/
│ │ ├── normalize-record.js
│ │ └── validate-record.js
│ ├── reports/
│ │ └── create-report.js
│ └── main.js
├── tests/
│ ├── records/
│ │ ├── normalize-record.test.js
│ │ └── validate-record.test.js
│ └── reports/
│ └── create-report.test.js
├── generated/
│ └── types.js
├── tools/
│ └── check-code.js
├── package.json
└── README.md
程式碼規範需要記錄目標系統實際採用的模組邊界,不能只要求「降低耦合」。每個模組至少要說明主要責任、公開介面、允許依賴的對象,以及哪些內容不得由其他模組直接使用。
相依規則可以從下列原則建立:
專案不一定要採用特定分層模式。重點是相依規則符合實際設計,而且能透過目錄限制、模組系統、架構測試或靜態分析檢查。只有架構圖而沒有檢查方式,程式結構仍可能在日常修改中逐漸偏離設計。
模組公開介面的參數、傳回內容、錯誤與狀態變更一旦被其他程式使用,就形成需要維護的約定。變更介面前要確認呼叫位置、相容範圍及移除舊介面的條件,避免只修改提供端,讓其他程式在執行時才發現不相容。
如果呼叫位置無法同時修改,可以暫時保留舊介面,清楚標示替代方式與移除條件:
function findRecord(source, { id }) {
return source.findById(id);
}
/**
* @deprecated 改用 findRecord(source, { id })。
*/
function findRecordById(source, id) {
return findRecord(source, { id });
}
如果所有呼叫位置能在同一次變更中完成調整,就應該一起更新並移除舊介面,不必為未確認的需求永久保留相容層。確實需要過渡期時,則要透過程式碼檢查、待辦項目或修訂紀錄追蹤剩餘使用位置。
同一類基礎處理如果由每個模組自行決定,呼叫端就要理解多套結果。規範應該先定義共同原則,再讓各功能補充自己的規則。
空值、缺少欄位、空字串、0 與 false 具有不同意義,規範不應該讓它們互相替代。如果系統會處理時間、數值或文字內容,也要依實際需要定義時區、單位、精度、捨入方式及文字編碼。輸入進入系統邊界時先轉成共同表示方式,內部程式就不必在每次使用時重新猜測資料意義。
例如,更新資料時可以明確定義各種值的意義:
const updateRecordInput = {
name: undefined, // 不修改既有名稱
note: null, // 清除既有內容
retryCount: 0, // 明確表示不重試
timeoutMilliseconds: 1500, // 單位固定為毫秒
};
這些意義要寫入型別、結構描述或公開介面文件,不能只存在範例註解中。不同輸入方式如果使用不同表示法,應該在各自邊界完成轉換,再交給功能程式處理。
輸入進入系統邊界時,先確認格式、必要欄位、型別及可解析性。與功能狀態及計算結果有關的條件,則由負責該功能的模組判斷。這項區分可以避免同一項功能規則分散在多個入口,也能讓不同輸入方式共用一致結果。
輸入驗證要限制預期格式、長度與範圍,不能只確認資料可以被語言解析。程式將資料傳給其他組成項目時,應該使用所選技術提供的結構化介面。如果資料需要寫入另一種語法或輸出格式,則要按照實際位置使用對應的編碼或跳脫方式,不要自行拼接可執行內容。
驗證失敗時,要使用穩定方式指出失敗類型與必要位置,不要把原始輸入全部寫入錯誤內容。輸入如果可能包含敏感資訊,還要先定義遮蔽或省略方式。
例如,輸入驗證可以只回傳穩定的欄位名稱與錯誤代碼,不包含原始輸入值:
function validateImportInput(input) {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return [{ field: "$", code: "invalid_type" }];
}
const issues = [];
if (typeof input.recordId !== "string" || input.recordId.trim() === "") {
issues.push({ field: "recordId", code: "required" });
}
if (!Number.isInteger(input.retryCount) || input.retryCount < 0) {
issues.push({ field: "retryCount", code: "invalid_range" });
}
return issues;
}
例如,識別碼只允許已定義的字元與長度,再透過資料來源提供的介面取得內容:
const recordIdPattern = /^[A-Z0-9-]{1,32}$/u;
function loadRecord(source, rawRecordId) {
const recordId = String(rawRecordId).trim();
if (!recordIdPattern.test(recordId)) {
throw new TypeError("recordId 格式不正確");
}
return source.findById(recordId);
}
如果目標系統確實需要組合查詢、指令、標記語言或其他具有特殊語法的內容,規範還要指定可以使用的函式庫或介面,以及禁止直接串接輸入的範圍。密碼、存取權杖、憑證、私密金鑰及其他敏感資訊仍不得寫入程式碼、錯誤內容或執行紀錄。
規範要區分可以預期的功能失敗、無效輸入、相依項目失敗及未預期錯誤。每一類錯誤都要定義在哪個邊界轉換、需要保留哪些原因,以及呼叫端可以採取甚麼動作。
捕捉錯誤後不能只回傳空值、一般失敗結果或無內容訊息。需要轉換錯誤時,應該保留原始原因與相關脈絡,並避免重複記錄同一項失敗。無法在目前層級處理的錯誤要繼續傳遞到已定義的處理邊界。
例如,在相依項目邊界轉換錯誤時,可以提供穩定的錯誤代碼並透過 cause 保留原始原因:
class DependencyError extends Error {
constructor(operation, cause) {
super(`相依項目無法完成 ${operation}`, { cause });
this.name = "DependencyError";
this.code = "dependency_failure";
}
}
async function saveRecord(repository, record) {
try {
return await repository.save(record);
} catch (error) {
throw new DependencyError("save_record", error);
}
}
如果目標系統需要執行紀錄,應該統一層級、欄位、事件名稱及關聯方式。紀錄內容要協助還原執行路徑與判斷失敗位置,避免只留下「發生錯誤」或整個物件的文字輸出。
例如,失敗紀錄可以使用固定事件名稱及關聯欄位,並只留下判斷問題所需的錯誤類型:
function recordImportFailure(writeLog, context, error) {
writeLog("error", {
event: "record_import_failed",
operationId: context.operationId,
recordId: context.recordId,
errorType: error.name,
});
}
密碼、存取權杖、憑證、私密金鑰及其他敏感資訊不得進入執行紀錄。個人資料或大量輸入內容也要按照實際保存與查詢需求限制範圍。只有在系統確實需要時才加入紀錄,不要讓每個函式都產生沒有用途的訊息。
設定要有明確結構、必要欄位、預設值與啟動時的檢查方式。程式不要把特定執行環境的位置、連線內容或功能開關散落在實作中。需要敏感資訊時,只保存取得方式及欄位定義,實際內容由已確認的安全設定機制提供。
例如,可以在建立設定時套用預設值並檢查格式,讓無效設定在功能啟動前就明確失敗:
function createImportSettings(rawSettings) {
const batchSize = Number(rawSettings.batchSize ?? 100);
if (!Number.isInteger(batchSize) || batchSize <= 0) {
throw new TypeError("batchSize 必須是正整數");
}
return Object.freeze({ batchSize });
}
每個模組只取得自己需要的設定,避免傳入包含所有設定的可變物件。設定變更如果會影響功能結果,也要納入測試與版本管理範圍。
如果程式會取得需要關閉或釋放的資源,規範要定義由哪一層取得、由哪一層釋放,以及正常完成、發生錯誤或取消處理時的清理方式。資源不應該交給不清楚生命週期的共用狀態,也不要依賴執行環境在無法預期的時間代為釋放。
例如,使用 finally 確保讀取成功或失敗後都會關閉資料來源:
async function readRecords(openSource) {
const source = await openSource();
try {
return await source.readAll();
} finally {
await source.close();
}
}
如果資源具有最大使用時間、同時使用數量或重複釋放限制,也要在取得介面或管理元件中統一處理。呼叫端只需要遵守已定義的生命週期,不應該各自建立另一套清理方式。
只有目標系統確實使用非同步或並行處理時,才需要加入相關規則。規範至少要說明執行順序、共享狀態的修改方式、取消與逾時如何傳遞,以及哪些失敗可以重試。可能改變狀態的動作還要先確認重複執行是否安全,不能因為發生錯誤就一律重新執行。
例如,下列讀取操作限制最多嘗試次數,並在每次執行前檢查取消狀態:
async function loadWithRetry(load, maxAttempts, signal) {
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new TypeError("maxAttempts 必須是大於 0 的整數");
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal?.aborted) {
throw new Error("處理已取消");
}
try {
return await load({ signal });
} catch (error) {
const shouldStop =
error?.code !== "TEMPORARY_UNAVAILABLE" ||
attempt === maxAttempts;
if (shouldStop) throw error;
}
}
}
實際規範還要按照相依項目的特性定義等待方式、逾時上限及可重試錯誤。多個處理流程可能修改相同狀態時,則要指定同步方式或避免共享可變狀態,並以測試確認執行順序不會改變功能結果。
建立共用程式碼的條件要比「看起來重複」更明確。兩段程式只有在目的、規則及變更原因一致時才適合共用。抽象介面則應該用來隔離已確認的變化、建立必要測試邊界,或隱藏不應散布的技術細節。
例如,下列自行定義的函式只把參數原樣傳入另一個函式,再直接傳回結果:
function getRecord(id) {
return loadRecord(id);
}
如果這層包裝沒有加入輸入檢查、資料轉換、錯誤處理,或形成需要隔離的相依邊界,呼叫端可以直接使用原函式。等到變化或共用需求已經出現,再建立責任清楚的抽象,可以減少不必要的呼叫層次、測試範圍及修改位置。
下列情況適合先保持具體寫法:
Common、Base 或 Generic 等詞,無法說明具體責任。確認抽象需求後,可以依語言能力與實際責任,善用模組(module)、命名空間(namespace)、類別(class)及介面(interface)等機制管理抽象:
例如,在 TypeScript 模組中,可以用命名空間集中識別碼規則、介面描述資料來源能力,再由類別封裝讀取流程:
export interface RecordData {
id: string;
name: string;
}
export interface RecordSource {
findById(recordId: string): Promise<RecordData | undefined>;
}
export namespace RecordId {
const pattern = /^[A-Z0-9-]{1,32}$/u;
export function parse(rawValue: string): string {
const recordId = rawValue.trim();
if (!pattern.test(recordId)) {
throw new TypeError("recordId 格式不正確");
}
return recordId;
}
}
export class RecordReader {
constructor(private readonly source: RecordSource) {}
read(rawRecordId: string): Promise<RecordData | undefined> {
const recordId = RecordId.parse(rawRecordId);
return this.source.findById(recordId);
}
}
管理同一項抽象時,應該選擇足以表達責任的最少機制,不需要同時建立命名空間、介面、基底類別及多層實作。公開名稱與相依方向也要反映功能責任,避免只按照技術種類分組。
建立共用元件後,要定義公開範圍、相依方向、狀態管理及失敗方式。呼叫端不應該依賴內部實作,也不應該透過全域可變狀態交換資料。如果新需求持續要求共用元件加入無關責任,就要拆分或停止擴大抽象範圍。
單元測試(Unit Test)要快速、可重複,而且能清楚指出哪一項規則失敗。測試單位可以是函式、類別或小型模組,實際範圍取決於設計邊界,不以行數決定。
測試規範可以包含下列內容:
例如,使用 JavaScript 且已選擇 Node.js 內建測試工具時,可以用一致的準備、執行與確認結構涵蓋正常及錯誤情況:
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeRecords } from "../src/records/normalize-record.js";
test("輸入包含無效紀錄時,只傳回有效且完成整理的紀錄", () => {
// 準備
const records = [
{ id: 3, value: 10.129 },
{ id: 2, value: Number.NaN },
{ id: 1, value: 8.555 },
];
const originalRecords = structuredClone(records);
// 執行
const result = normalizeRecords(records, 8);
// 確認
assert.deepEqual(result, [
{ id: 1, value: 8.56 },
{ id: 3, value: 10.13 },
]);
assert.deepEqual(records, originalRecords);
});
test("records 不是陣列時,拋出 TypeError", () => {
assert.throws(() => normalizeRecords(null), TypeError);
});
測試名稱直接說明條件與預期結果,第一個測試同時確認篩選、數值整理、排序及輸入內容沒有改變,第二個測試則確認文件已記錄的失敗方式。實際專案應該按照已選定的語言與測試工具採用對應語法,不需要跨技術統一成相同寫法。
測試涵蓋率(Test Coverage)可以協助找出未執行的程式路徑,不能單獨證明測試有效。專案可以設定合理門檻,但仍要檢查重要功能規則、邊界與失敗路徑是否有具體測試。只為提高數字而執行程式,無法取代對預期結果的確認。
格式化工具、程式碼檢查工具(Linter)與靜態分析(Static Analysis)負責的範圍不同。格式化工具統一文字呈現,程式碼檢查工具找出特定語法或慣例問題,靜態分析則可能利用型別、控制流程或資料流找出不一致與風險。這些工具要和編譯、測試及建置一起形成共同檢查流程。
導入時可以採用下列順序:
例如,已使用 npm 的 JavaScript 與 TypeScript 專案可以在 package.json 提供單一共同入口:
{
"scripts": {
"format:check": "prettier --check .",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "node --test",
"build": "tsc --project tsconfig.build.json",
"check": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build"
}
}
本機開發與自動化流程都執行 npm run check,就能使用相同順序與設定。實際工具、指令及處理範圍仍要按照專案選定的技術調整,並在各工具設定中排除不需要檢查的自動產生或外部來源內容。
如果某條規則經常需要略過,應該檢查規則是否符合所選技術與實際程式結構。確有必要的單一例外可以附上原因及限制範圍,不能直接停用整個專案的檢查。
自動化流程只能判斷已經轉成工具設定的規則。模組責任、公開介面、抽象範圍、資料擁有權及錯誤資訊是否足夠,仍需要透過程式碼審查確認。審查範圍應該包含實作、測試、設定、文件與自動產生內容的來源,避免只查看變更行數而忽略整體影響。
審查意見可以區分為阻擋、警告、建議與詢問,並引用對應規則識別碼。通過條件至少包含自動檢查已通過、阻擋問題已處理、必要測試與文件已更新,以及所有例外都留下適用範圍與重新檢查條件。如果由多人共同維護程式碼,還要依變更範圍指定適合的審查人員與核准數量。
例如,一次審查結果可以記錄成下列形式:
變更範圍:
- src/records/normalize-record.js
- tests/records/normalize-record.test.js
自動檢查: 通過
人工檢查:
- 規則: no-undef
結果: 通過
- 規則: CODE-BOUNDARY-002
結果: 通過
未處理阻擋問題: 0
例外紀錄: []
審查結果: 通過
審查通過表示這次變更符合目前規範,且沒有讓已確認的品質與維護性降低。仍可留下不影響通過的改善建議,但純粹出自個人偏好的內容不應該成為阻擋條件。審查意見無法取得共識時,應該回到規則內容、技術結果及既有設計判斷,必要時由對應程式範圍的維護人員確認處理方式。
容易產生不同解讀的規則需要正反例。範例應該取自目標系統會出現的程式結構,並且只呈現該規則需要比較的差異。
| 規範目的 | 不足的做法 | 較清楚的做法 |
|---|---|---|
| 表達函式用途 | 使用 processData,無法判斷處理內容與結果 |
使用能表達主要動作及對象的名稱,例如 normalizeSourceRecord |
| 表達變數用途 | 使用 data1、item2 或只有型別資訊的名稱 |
使用能表達內容及用途的名稱,並在用途改變時建立新變數 |
| 維持名稱一致 | 以多個未定義的動詞表示同一項動作 | 選定一個符合實際行為的動詞,並在相同責任中一致使用 |
| 揭露狀態變更 | 名稱看似只查詢結果,執行時卻修改其他狀態 | 拆分查詢與修改責任,或讓名稱及介面清楚呈現狀態變更 |
| 維持模組邊界 | 直接讀取另一個模組的內部檔案 | 透過該模組已定義的公開介面取得結果 |
| 保留錯誤原因 | 捕捉所有錯誤後只回傳 false |
轉換成已定義的錯誤類型,保留原始原因與必要脈絡 |
| 建立共用程式碼 | 看到兩段相似內容就移入萬用工具模組 | 先確認目的、規則及變更原因一致,再建立責任清楚的元件 |
| 撰寫單元測試 | 測試名稱只寫「測試功能」 | 在名稱中寫出條件、動作與預期結果 |
有些限制可能要求暫時偏離共同規範。例外紀錄要包含適用規則、程式範圍、原因、已知影響、替代檢查、負責人及重新檢查條件。能縮小到單行或單一檔案時,就不要停用整個模組。限制消失後要移除例外,避免暫時決定變成無期限慣例。
規範本身也需要版本管理及審查。規範維護人員要定期確認規則仍符合實際程式結構,並處理已經到達重新檢查條件的例外。語言、框架或工具版本改變時,應該同步更新規範、設定、範例與自動化流程。修訂紀錄要說明變更原因、影響範圍及既有程式的處理方式,讓開發人員可以採用同一個基準。
程式碼規範完成時,至少要能確認下列結果:
如果規範只能在文件中閱讀,實際程式卻無法自動檢查或在審查時判斷,就還沒有形成共同標準。先用一條具代表性的功能路徑套用全部規則,可以找出互相衝突、過度嚴格或仍然含糊的項目,再擴大到後續實作。
例如,一條代表性功能路徑完成檢查後,可以保存下列結果:
規範版本: 1.0.0
代表功能: 紀錄正規化
套用範圍:
- src/records/
- tests/records/
自動檢查:
格式: 通過
程式碼檢查: 通過
靜態分析: 通過
測試: 通過
建置: 通過
人工檢查:
命名與責任: 通過
資料擁有權: 通過
模組相依方向: 通過
未處理例外: 0
完成結果: 可以作為後續功能的共同基準
這份結果要能連回使用的規範版本、工具設定、測試與審查紀錄。後續功能如果仍需要依靠未記錄的做法,或同一項規則在不同位置產生不同結果,就要先修訂規範再繼續擴大套用範圍。
0 與 false 要有明確且一致的表示方式;輸入驗證、安全處理、錯誤、執行紀錄、設定及資源生命週期則要在已定義的邊界處理,避免敏感資訊與環境細節散布到程式中。